开源教程 | 用 Mapbox 进行高级地图着色(下)柔和阴影与环境光 - Mapbox 一分钟
我们上个礼拜为大家带来的地图着色教程,已经帮助大家实现了这样的效果。
本期我们将会一起用 柔和阴影(soft shadows)和环境照明(ambient lighting)完善自己的作品。为了提高速度,我们将会使用稍微有点难理解的 regl 库,并在 WebGL 的 GPU 上运行。
预期效果如下👇
添加柔和阴影
添加环境光
添加柔和阴影和环境光
添加卫星图
🌈柔和阴影(Soft Shadows)
注意:对于本节和以下部分,我们将地形缩放四倍以增加着色效果的对比度。当然,你也可以换一个值,这是艺术选择。
真正的光,如太阳,有一定的尺寸,它们不是无限小的点光源。这就是创造柔和阴影的原因,如下图:
从 A 点的角度来看,可以看到整个光。从 B 点只能看到一部分光,而 C 点看不到任何光,因为它被完全遮挡。
想象一下,从 A 点移动到 C 点。最初,你看到整个光线,然后阻挡了一小部分光线,然后越来越多的光被阻挡,直到你看不到任何光线并完全处于阴影中。正是这种转变产生了柔和的阴影。
另外,光的大小影响柔和阴影的大小。光源越大,阴影越柔和。如果你从我们刚刚描述的从 A 到 C 的转换来考虑,是有道理的 - 从完全可见光转换到完全遮挡的光需要更长的时间。
为了计算柔和阴影,我们将把每个像素的光线投射到太阳上的一个随机点,多次,并在每个光线的强度上平均。当我们位于 A 点时,我们所有的光线都会照射到太阳上。在 B 点,有些光会照到太阳,有些会被挡住。在 C 点,所有都将被挡住。所有这些命中和未命中的平均值都会产生我们柔和阴影的值。
下面,我们来谈谈如何投射光线。
👀快速像素遍历(Fast Pixel Traversal)
John Amanatides 和 Andrew Woo 在 1987 年发表了 《A Fast Voxel Traversal Algorithm for Ray Tracing》这篇论文。下面对论文中提到的一些重要概念,做个简单的概括。
这个算法由两个阶段构成:初始化和遍历。
首先,来看下初始化。如下代码,我们从像素 p 开始,然后我们找到沿每个轴(stp)的光线方向的标志。接下来,看看需要在光线方向上行进多远。,才能与 x 和 y 方向上的下一个像素(tMax)相交。最后,确定必须沿着光线行进多远,才能覆盖像素的宽度和高度(tDelta)。
此时,我们可以开始算法的遍历阶段。这是伪代码:
while (true) {
if (tMax.x < tMax.y) {
tMax.x += tDelta.x
p.x += stp.x
} else {
tMax.y += tDelta.y
p.y += stp.y
}
if (exitedTile() or hitTerrain()) {
break
}
}
是不是特别简单?我们可以遍历整个纹理,不会遗漏任何像素。
👀Ping-pong 技术
我们平均所有阴影射线的方式是使用 Ping-pong 技术。我们将渲染到目标帧缓冲区,并在下一次迭代中将其用作源纹理。然后我们将再次交换它们并重复这个过程。
我们将渲染固定次数(N),因此每次迭代我们都会将 1 / N 加到我们累积的照明上。当完成时,我们渲染的最后一个帧缓冲区包含最终平均结果。可以看下下面的 Demo。
function PingPong(regl, opts) {
const fbos = [regl.framebuffer(opts), regl.framebuffer(opts)];
let index = 0;
function ping() {
return fbos[index];
}
function pong() {
return fbos[1 - index];
}
function swap() {
index = 1 - index;
}
return {
ping,
pong,
swap
};
}
👀执行过程
下面我们一起来执行一遍。
首先,建立 ping-pong 帧缓冲区。
const shadowPP = demo.PingPong(regl, {
width: image.width,
height: image.height,
colorType: "float"
});
下面,写一个 regl 命令,用来计算单个光线的光照,并将其添加到最后一次迭代的结果中。顶点着色器保持不变。
const cmdSoftShadows = regl({
vert: `
precision highp float;
attribute vec2 position;
void main() {
gl_Position = vec4(position, 0, 1);
}
`,
下面我们启动片段着色器(fragment shader)。
frag: `
precision highp float;
uniform sampler2D tElevation;
uniform sampler2D tNormal;
uniform sampler2D tSrc;
uniform vec3 sunDirection;
uniform vec2 resolution;
uniform float pixelScale;
void main() {
vec2 ires = 1.0 / resolution;
我们将从它们各自的纹理和前一次迭代(src)的照明中获取法线和高程。
vec3 src = texture2D(tSrc, gl_FragCoord.xy * ires).rgb;
vec4 e0 = texture2D(tElevation, gl_FragCoord.xy * ires);
vec3 n0 = texture2D(tNormal, gl_FragCoord.xy * ires).rgb;
然后,我们将对太阳方向的二维分量进行归一化以得到我们的 2D 射线方向 sr。
vec2 sr = normalize(sunDirection.xy);
下面,我们将如上所述初始化像素遍历算法。
vec2 p0 = gl_FragCoord.xy;
vec2 p = floor(p0);
vec2 stp = sign(sr);
vec2 tMax = step(0.0, sr) * (1.0 - fract(p0)) + (1.0 - step(0.0, sr)) * fract(p0);
tMax /= abs(sr);
vec2 tDelta = 1.0 / abs(sr);
开始遍历。
for (int i = 0; i < 65536; i++) {
if (tMax.x < tMax.y) {
tMax.x += tDelta.x;
p.x += stp.x;
} else {
tMax.y += tDelta.y;
p.y += stp.y;
}
在每一步,我们将获取当前像素中心的标准化纹理坐标,并检查我们是否已离开瓦片的边界。如果离开了,我们将为此迭代添加一些照明,并停止遍历。
如下代码,我们正在执行 128 次迭代,这些迭代导致了数学误差,这里的点积考虑了照明角度。
vec2 ptex = ires * (p + 0.5);
if (ptex.x < 0.0 || ptex.x > 1.0 || ptex.y < 0.0 || ptex.y > 1.0) {
gl_FragColor = vec4(src + vec3(1.0/128.0) * clamp(dot(n0, sunDirection), 0.0, 1.0), 1.0);
return;
}
如果我们没有离开瓦片,需要看是否碰撞到任何地形。
让我们先得到当前像素的高程。
vec4 e = texture2D(tElevation, ptex);
我们将计算我们沿着 2D 光线行进的时间(t),并使用它来确定我们从起点沿着 3D 光线
到当前点的高程。
float t = distance(p + 0.5, p0);
float z = e0.r + t * pixelScale * sunDirection.z;
如果我们所在的像素的高程大于我们沿着 3D 光线的高程,就会撞到地形。我们将存储上一次迭代的照明,并为此迭代添加零。
if (e.r > z) {
gl_FragColor = vec4(src, 1.0);
return;
}
我们设置循环迭代次数比遍历瓦片所需的次数多,所以我们一般不会遇到这种情况。但如果完成循环,让我们假装它已被照亮。你也可以在这里渲染一个独特的颜色来尝试调试。
}
gl_FragColor = vec4(src + vec3(1.0/128.0) * clamp(dot(n0, sunDirection), 0.0, 1.0), 1.0);
}
`,
regl 命令有一些值得注意的地方
禁用深度缓冲区以确保每次都可以写入
为 sunDirection 以及源和目标帧缓冲区(分别为 src 和 dest)添加 regl props(本质上是参数)。
depth: {
enable: false
},
attributes: {
position: [-1, -1, 1, -1, 1, 1, -1, -1, 1, 1, -1, 1]
},
uniforms: {
tElevation: fboElevation,
tNormal: fboNormal,
tSrc: regl.prop("src"),
sunDirection: regl.prop("sunDirection"),
pixelScale: pixelScale,
resolution: [image.width, image.height]
},
viewport: { x: 0, y: 0, width: image.width, height: image.height },
framebuffer: regl.prop("dest"),
count: 6
});
现在命令已经完成,我们将调用它 128 次(更早回调标准化常量)。每次调用它时,我们都会交换 ping-pong 帧缓冲区并计算新的太阳方向。下面的 100 表示太阳半径的倍数,因此我们使用的假太阳比真实太阳大 100 倍(就其半径而言)。
在最后一帧(i === 127),我们将渲染到屏幕上,而不是帧缓冲。
for (let i = 0; i < 128; i++) {
cmdSoftShadows({
sunDirection: vec3.normalize(
[],
vec3.add(
[],
vec3.scale([], vec3.normalize([], [1, 1, 1]), 149600000000),
vec3.random([], 695508000 * 100)
)
),
src: shadowPP.ping(),
dest: i === 127 ? undefined : shadowPP.pong()
});
shadowPP.swap();
}
最终结果如下。
软阴影
这里我们用硬阴影来做个对比(渲染的时候,上面提到的 100 被设置为 0)。
硬阴影
🌈环境光(Ambient Lighting)
环境光是到达表面的所有光的总和。在地图应用程序的上下文中,我们认为这是从天空照射地图的光,而不是太阳。重要的是,这种光被附近的地形遮挡,所以高原的环境光量相对高,而深入缝隙的环境光量应该很低。
计算环境光量与为软阴影执行的光照计算非常相似。主要的不同是,我们不是将射线射向太阳,而是从地面上随机发射,看看它们是否撞击地形。如果不撞击,就增加照明;如果撞击,就不增加照明。
由于我们再次取平均值,还需要在此处创建 ping-pong 帧缓冲区。
const ambientPP = demo.PingPong(regl, {
width: image.width,
height: image.height,
colorType: "float"
});
regl 命令几乎相同,下面是不同的地方。
const cmdAmbient = regl({
vert: `
precision highp float;
attribute vec2 position;
void main() {
gl_Position = vec4(position, 0, 1);
}
`,
frag: `
precision highp float;
uniform sampler2D tElevation;
uniform sampler2D tNormal;
uniform sampler2D tSrc;
uniform vec3 direction;
uniform vec2 resolution;
uniform float pixelScale;
void main() {
vec2 ires = 1.0 / resolution;
vec3 src = texture2D(tSrc, gl_FragCoord.xy * ires).rgb;
vec4 e0 = texture2D(tElevation, gl_FragCoord.xy * ires);
vec3 n0 = texture2D(tNormal, gl_FragCoord.xy * ires).rgb;
以下是我们计算光线方向的方法👇
想象一个半径为 1 米的球体在当前像素的表面上。从球体的体积中随机选择一个点。创建从曲面点到随机点的矢量,并对其进行标准化。这就是是光线方向。这样可以产生一个光线分布,类似于光线从完全漫反射的表面反射。
在代码中实现起来非常简单。
vec3 sr3d = normalize(n0 + direction);
The rest of the regl command is pretty much identical, except that we don’t scale the illumination by the dot product of the surface normal with the ray direction - that’s already taken care of by the distribution of rays we’re generating.
vec2 sr = normalize(sr3d.xy);
vec2 p0 = gl_FragCoord.xy;
vec2 p = floor(p0);
vec2 stp = sign(sr);
vec2 tMax = step(0.0, sr) * (1.0 - fract(p0)) + (1.0 - step(0.0, sr)) * fract(p0);
tMax /= abs(sr);
vec2 tDelta = 1.0 / abs(sr);
for (int i = 0; i < 65536; i++) {
if (tMax.x < tMax.y) {
tMax.x += tDelta.x;
p.x += stp.x;
} else {
tMax.y += tDelta.y;
p.y += stp.y;
}
vec2 ptex = ires * (p + 0.5);
if (ptex.x < 0.0 || ptex.x > 1.0 || ptex.y < 0.0 || ptex.y > 1.0) {
gl_FragColor = vec4(src + vec3(1.0/128.0), 1.0);
return;
}
vec4 e = texture2D(tElevation, ptex);
float t = distance(p + 0.5, p0);
float z = e0.r + t * pixelScale * sr3d.z;
if (e.r > z) {
gl_FragColor = vec4(src, 1.0);
return;
}
}
gl_FragColor = vec4(src + vec3(1.0/128.0), 1.0);
}
`,
depth: {
enable: false
},
attributes: {
position: [-1, -1, 1, -1, 1, 1, -1, -1, 1, 1, -1, 1]
},
uniforms: {
tElevation: fboElevation,
tNormal: fboNormal,
tSrc: regl.prop("src"),
direction: regl.prop("direction"),
pixelScale: pixelScale,
resolution: [image.width, image.height]
},
viewport: { x: 0, y: 0, width: image.width, height: image.height },
framebuffer: regl.prop("dest"),
count: 6
});
最后,我们提供的随机矢量,在一个单位球中具有随机长度。
for(let i = 0; i <128; i ++){
cmdAmbient({
direction:vec3.random([],Math.random()),
src:ambientPP.ping(),
dest:i === 127?undefined:ambientPP.pong()
});
ambientPP.swap();
}
环境光照效果
🌈组合光
源码
现在我们已经拥有了软阴影和环境光两个照明组件,我们可以简单地将它们组合在一起以创建最终的地图效果。
在组合之前,我们需要更改 cmdSoftShadow 和 cmdAmbient 中的下面几行。
dest: i === 127 ? undefined : shadowPP.pong()
dest: i === 127 ? undefined : ambientPP.pong()
我们需要将上面两行修改为下面两行。
dest: shadowPP.pong()
dest: ambientPP.pong()
这样,我们将每个组件存储在 ping-pong 帧缓冲区的 ping() 中。请注意,虽然最终目标是 pong(),但是在循环结束时会发生最终交换。
添加照明有一种艺术化的方式,我们将每个光照分量乘以个因子。比如下面代码中,软阴影照明的因数为 1.0,环境光的因数为 0.25。您可以根据自己的喜好去自由调整。
const cmdFinal = regl({
vert: `
precision highp float;
attribute vec2 position;
void main() {
gl_Position = vec4(position, 0, 1);
}
`,
frag: `
precision highp float;
uniform sampler2D tSoftShadow;
uniform sampler2D tAmbient;
uniform vec2 resolution;
void main() {
vec2 ires = 1.0 / resolution;
float softShadow = texture2D(tSoftShadow, ires * gl_FragCoord.xy).r;
float ambient = texture2D(tAmbient, ires * gl_FragCoord.xy).r;
float l = 1.0 * softShadow + 0.25 * ambient;
gl_FragColor = vec4(l,l,l, 1.0);
}
`,
depth: {
enable: false
},
attributes: {
position: [-1, -1, 1, -1, 1, 1, -1, -1, 1, 1, -1, 1]
},
uniforms: {
tSoftShadow: shadowPP.ping(),
tAmbient: ambientPP.ping(),
resolution: [image.width, image.height]
},
viewport: { x: 0, y: 0, width: image.width, height: image.height },
count: 6
});
cmdFinal();
最终结果如下所示。
软阴影与环境光结合的效果
如果调整太阳半径的倍数,比如设置为 1,是这样的效果。
软阴影和环境光结合,太阳半径设置为 1 倍
🌈添加颜色
我们可以添加任意基色来应用我们的照明。比如我们来试试添加卫星图像。
注意:卫星图像已经带有来自太阳的烘烤照明。如果放大得足够近,通常可以看到阴影。目前可能还没有一种很好的方法可以直接解决这个问题(虽然有论文在研究卫星阴影去除)。最简单的办法可能是缩小或使用不同的图像集。
让我们从 Mapbox 下载卫星瓦片并用它创建一个纹理。
const satelliteImage = await demo.loadImage(
`https://api.mapbox.com/v4/mapbox.satellite/${zoom}/${tLong}/${tLat}.pngraw?access_token=${MAPBOX_KEY}`
);
const tSatellite = regl.texture({
data: satelliteImage,
flipY: true
});
In our fragment shader, we’ll pull in the satellite texture:
uniform sampler2D tSatellite;
We’ll grab the texture color:
vec3 satellite = texture2D(tSatellite, ires * gl_FragCoord.xy).rgb;
Tweak our lighting a bit:
float l = 4.0 * softShadow + 0.25 * ambient;
通过应用曲线稍微加深卫星纹理的颜色。
vec3 color = l * pow(satellite, vec3(2.0));
最后用伽马矫正,展示结果。
color = pow(color, vec3(1.0/2.2));
gl_FragColor = vec4(color, 1.0);
当太阳半径倍数是 100 的时候,结果如下。
结合软阴影和环境光照,叠加卫星图像,太阳半径倍数是 100 的效果
当太阳半径倍数是 1 的时候的效果如下。
结合软阴影和环境光照,叠加卫星图像,太阳半径倍数是 1 的效果
当然,您也可以尝试使用任何一种地图样式,比如下面这样。
结合软阴影和环境光照,叠加 Mapbox Street Tiles,太阳半径倍数是 100 的效果
🌈Tiling
下面是两个相邻的瓦片。
两个独立渲染的瓦片
单独来看,它们看起来是很不错的。但是,如果并排显示其中两个瓦片,则会有点不太契合,如下图所示。
哎,怎么会这样呢?
有这么几种可能的原因导致这样:
首先,如前所述,每个瓦片的正边缘处的法线是不明确的。
其次,软阴影和环境光照明的问题也存在同样的问题,但会更糟糕一些,因为它们需要的信息更多 —— 它们需要能看到,来自太阳或天空的,任何可能遮挡它们的东西。
如何解决这个问题呢?有一种方法是渲染目标图块及其周围的八个图块,然后提取目标图块。一起来看一下怎么做吧。
首先,让我们编写一个返回 Canvas 对象的函数,该对象包含中心的目标图块和八个周围的图块。
async function getRegion(tLat, tLong, zoom, api) {
const canvas = document.createElement("canvas");
canvas.width = 3 * 256;
canvas.height = 3 * 256;
const ctx = canvas.getContext("2d");
for (let x = 0; x < 3; x++) {
const _tLong = tLong + (x - 1);
for (let y = 0; y < 3; y++) {
const _tLat = tLat + (y - 1);
const url = api
.replace("zoom", zoom)
.replace("tLat", _tLat)
.replace("tLong", _tLong);
const img = await loadImage(url);
ctx.drawImage(img, x * 256, y * 256);
}
}
return canvas;
}
下面,我们来提取它的高程。
const image = await demo.getRegion(
tLat,
tLong,
zoom,
`https://api.mapbox.com/v4/mapbox.terrain-rgb/zoom/tLong/tLat.pngraw?access_token=${MAPBOX_KEY}`
);
就像下面这样。
在单个图像中定位具有八个邻居的 terrain-rgb 瓦片
接下来,我们需要改变计算 pixelScale 的方式,以便考虑新的经度范围。
long0 = demo.tile2long(tLong - 1, zoom);
long1 = demo.tile2long(tLong + 2, zoom);
const pixelScale =
(6371000 * (long1 - long0) * 2 * Math.PI) / 360 / image.width;
And of course we need the large satellite image as well:
const satelliteImage = await demo.getRegion(
tLat,
tLong,
zoom,
`https://api.mapbox.com/v4/mapbox.satellite/zoom/tLong/tLat.pngraw?access_token=${MAPBOX_KEY}`
);
现在我们像以前一样渲染相同的两个目标瓦片,但是相邻的图块,然后从每个图块中提取中心。首先是左边。
提取左边的瓦片
然后提取右边的瓦片。
最终结果好了很多,没有明显的不连接感。
👀Tiling 陷阱
如果您的阴影或环境光照效果拉伸超过一块瓦片,它们可能会产生伪影。可能的解决办法如下:
对于出现此问题的阴影,可以尝试抬高光源,使它们不会被拉伸到远处。
可以增加渲染图块的半径,直到它们包含受阴影和环境光照影响的区域。
可以在正在渲染的切片半径上淡化阴影和遮挡的强度,强制它们在到达切片边缘之前为零。这可能会导致“方形”阴影和遮挡,但也可能会导致强度突然下降。
如果在对软阴影和环境光照进行平均时使用的样本数较少,则可能会看到每个瓦片的不同样本分布会产生伪影。这很容易解决:创建一个随机向量列表,然后为每个瓦片重用它们,而不是为每个瓦片生成一个新集合。
我还没有尝试过,但处理缩放功能可能会很棘手。我们将无法以高变焦计算光照,然后通过合并图像来构建较低缩放级别的图像。在几个缩放级别之后,阴影和环境光的效果将会消失,因为它们相对于地图的尺寸而言会很小。我们可能会尝试重新计算每个缩放级别的光照,适当增加或减少高程比例以使着色效果缩放。
👀Tiling 优化
我们需要计算所有瓦片中所有像素的光照。其实没有必要这样做。只需计算中心区域的像素即可。
缓存所有对缓存有意义的瓦片。没有必要为一个瓦片多次重新计算高程,也不需要多次下载它。
🌈距离最终作品就差一步了
整个项目的源码在这里👇
https://github.com/wwwtyro/map-tile-lighting-demo
想要运行这个 Demo,您需要提供自己的 Mapbox Key,创建帐户并获取密钥后,将其拖入 Demo Repo 根目录中名为 mapbox.key 的文件中。
当然,您也可以在 3D 网格上使用此技术。您只需要生成具有适当 UV 坐标的网格并应用您生成的纹理。
扫描文末二维码,在 Mapbox 微信公众号后台回复“渲染图”即可前下载根据本教程做出来的美国 50 个州的高清渲染图片,就像下面这样。
3D 的 Grand Canyon
2D 和 3D 的 San Francisco
作者的家乡 Westside El Paso
原文来源:
https://wwwtyro.net/2019/03/21/advanced-map-shading.html
👀相关阅读
关注公众号,回复“渲染图”提前下载根据本教程做出来的美国 50 个州的高清渲染图片哦。